Skip to content

feat(rapier-cluster): in-tick imperative physics ops via PhysicsHandle (#121)#126

Merged
martinjms merged 1 commit intofeat/rapier-clusterfrom
feat/121-in-tick-imperative-ops
May 4, 2026
Merged

feat(rapier-cluster): in-tick imperative physics ops via PhysicsHandle (#121)#126
martinjms merged 1 commit intofeat/rapier-clusterfrom
feat/121-in-tick-imperative-ops

Conversation

@martinjms
Copy link
Copy Markdown
Contributor

Quick Summary

  • Adds entity-keyed in-tick imperative physics ops to RapierClusterTickContext::physics: PhysicsHandle<'a>.
  • Closes the gap in Rapier physics: in-tick imperative ops (impulses, forces, raycasts, joints, teleport) #121 — gameplay can now apply impulses/forces, teleport, raycast, run intersection queries, and create joints from inside on_tick.
  • All ops are entity-keyed (take Uuid, never raw Rapier handles) and behave gracefully on missing ids and Fixed bodies.
  • Lock-window restructure: state lock is held during on_tick for the Rapier-aware path so PhysicsHandle can mutate state synchronously. Plain ClusterSimulation keeps the legacy lock-released behavior.

Change Type

  • feature

Impact

  • User/developer impact: Implementers of RapierClusterSimulation can now do all the things local Rapier users can — explosions (radius query + apply_impulse), hitscan weapons (raycast), teleport/respawn (set_translation), joints between entities. The unified-entity invariant is preserved (no off-spine bodies; no raw Rapier handles in user code).
  • Risk level: Low–medium. Internal lock-window change is the architecturally interesting piece, scoped to the Backend::Rapier path only. No replication / hot-path touches. All 76 prior rapier_cluster tests still pass after the restructure; 11 new tests cover the new ops.

Verification

Decisions made

  • Lock held during user on_tick only for Backend::Rapier. Considered: hold lock for all backends.
    Reason: Plain ClusterSimulation (Backend::Cluster) has no PhysicsHandle to give it, so there's no functional reason to hold the lock during its on_tick. Keeps the legacy behavior of releasing the lock for plain sims and avoids any subtle change in lock contention for users who haven't migrated.

  • pending_imperative_linvel: HashSet<Uuid> on RapierState (not on PhysicsHandle). Considered: store on PhysicsHandle, drain after on_tick.
    Reason: PhysicsHandle has lifetime 'a; storing the set on it would require either pub(crate) field access or an extraction method — both leak the lifecycle. Putting it on the underlying state is symmetric with pending_contact_events and avoids lifetime gymnastics.

  • Track only set_linvel/apply_impulse, not set_translation/set_angvel/apply_force. Considered: track all imperative mutations.
    Reason: The spawn-loop sync only does set_linvel(entry.velocity). It doesn't touch translation, angvel, or forces. So only linvel needs the override-protection; the others compose naturally with the sync (apply_force adds during the step regardless of set_linvel; set_translation is read-only from the spawn loop).

  • QueryPipeline constructed transiently per-query, not stored on RapierState. Considered: store + update once per step (matches the issue spec's wording "QueryPipeline integrated into RapierState, updated post-step").
    Reason: Rapier 0.32's QueryPipeline is QueryPipeline<'a> — a borrowed view bound to broad_phase + narrow_phase + bodies + colliders. It can no longer be a stored field. The BroadPhaseBvh::as_query_pipeline(...) factory builds it cheaply on demand. PhysicsHandle::raycast / intersections_with_shape construct it inline. Functionally equivalent to "post-step refreshed" — the broad_phase BVH is updated by physics_pipeline.step so all queries inside on_tick (which runs next tick, after the prior step) see the latest BVH.

  • JointSpec carries axis as plain Vec3; normalized inside create_joint. Considered: take UnitVector / Unit<Vector>.
    Reason: Rapier 0.32 takes Vector directly for joint axes (auto-normalizes via the builder); user-facing API is simpler. We call to_rapier(axis).normalize() to defend against zero/non-unit input.

  • JointId(ImpulseJointHandle) opaque newtype with private field. Considered: expose the Rapier handle directly via pub.
    Reason: Keeps the unified-entity invariant — user never holds a raw Rapier handle. JointId is Copy + Eq + Hash for ergonomic storage in user-side maps.

  • #[non_exhaustive] on JointSpec variants and RaycastHit with pub const fn new(...) constructor on RaycastHit. Considered: skip the constructor.
    Reason: Same #[non_exhaustive] external-construction issue we hit in Rapier physics: per-entity body kind + material + collision filtering + sensor hooks #120 — can't construct via struct literal outside the crate. Constructor allows users to build RaycastHit instances in test fixtures / mock setups. JointSpec has no constructor needed since users always go through create_joint.

  • Imperative ops on Fixed bodies silently no-op (return false). Considered: panic; or accept the op and let Rapier handle it.
    Reason: Per the issue's normalization spec — gameplay code shouldn't have to query body kind before applying force. Silent no-op + false return is the gentlest contract; matches the existing missing-entity behavior.

  • Test fixture ScriptedSim<F> parameterized by closure. Considered: per-test struct (existing pattern from Rapier physics: per-entity body kind + material + collision filtering + sensor hooks #120).
    Reason: 11 Rapier physics: in-tick imperative ops (impulses, forces, raycasts, joints, teleport) #121 tests all need different in-tick behaviors. A closure-parameterized fixture (Fn(&mut PhysicsHandle, u64)) plus run_with_action helper drops boilerplate from ~15 lines per test to ~5. Per-test structs still used where the test needs richer state (joint despawn cleanup, Fixed-body sim).

Implementation notes

Architecture fit

  • PhysicsHandle<'a> borrows &'a mut RapierState — same lifetime as the surrounding RapierClusterTickContext<'a>. After sim.on_tick(&mut rapier_ctx) returns, the inner block ends and the borrow on state releases, allowing run_physics_phase(&mut state, ctx) to use state directly.
  • New private method RapierClusterSim::run_physics_phase(&self, state, ctx) extracts the despawn / spawn / set_linvel-with-skip / step / sync_outputs sequence from the old monolithic on_tick. All three backends now share this phase via clean factoring.
  • pending_imperative_linvel is cleared at the start of every tick (in all three backend branches) and populated by apply_impulse / set_linvel. The spawn-loop sync skips entities present in the set.

Rapier 0.32 API notes

  • QueryPipeline<'a> is now a borrowed view from BroadPhaseBvh::as_query_pipeline(dispatcher, bodies, colliders, filter). Cannot be stored as a field.
  • Joint anchors take Vector (glam Vec3), not Point::from(...).
  • Joint axes take Vector; we .normalize() defensively.
  • RigidBody::linvel() / angvel() / translation() return by value (Vector), not by reference.
  • Ray::new(origin: Vector, dir: Vector) — both args are Vector; hit point is ray.origin + ray.dir * toi.
  • Pose::from_translation(vector) is the new translation-only constructor (replaces Isometry::translation(x, y, z)).

Module docs

  • New # In-tick imperative ops section enumerating the new methods + the lock-window contract.
  • Extended # Example block with an ActionGame impl showing hitscan raycast, explosion radius query + apply_impulse, and contact-driven teleport.

Tests

11 new tests appended to mod tests. Shared fixture ScriptedSim<F> + run_with_action helper for the closure-driven cases; per-test impl structs for despawn_cleans_up_attached_joints and imperative_ops_on_fixed_body_are_no_ops (richer state).

Reference

#121)

Add entity-keyed in-tick imperative operations to RapierClusterTickContext
via a new PhysicsHandle. Closes the "developers can run any Rapier
simulation in the cluster the same way they could locally" gap — previously
the only way to influence physics in a tick was entity.velocity mutation.

New public API surface:
- PhysicsHandle::{apply_impulse, apply_force, apply_torque_impulse,
  set_translation, set_linvel, set_angvel, linvel, angvel, wake, sleep,
  raycast, intersections_with_shape, create_joint, remove_joint}
- RaycastHit { entity_id, time_of_impact, point, normal } + ::new()
- JointSpec enum (Fixed / Revolute / Spherical / Prismatic) + JointId newtype
- physics: PhysicsHandle<'a> field on RapierClusterTickContext

Architecture changes:
- Lock-window restructure for the Backend::Rapier path: state lock now held
  during user on_tick so PhysicsHandle can mutate Rapier state synchronously.
  Backend::Cluster (plain ClusterSimulation) keeps releasing the lock during
  user code (legacy behavior; no PhysicsHandle to give it).
- Spawn-loop velocity sync now skips entities whose linvel was set
  imperatively this tick (apply_impulse / set_linvel mark touched_linvel),
  preserving the imperative override.
- Imperative ops on Fixed bodies silently no-op and return false.
- Operations on missing entity_ids return false / None without panicking.
- Joint cleanup: bodies.remove auto-removes attached joints (existing
  behavior preserved); remove_joint after despawn returns false.

Rapier 0.32 API adjustment: QueryPipeline is constructed transiently per
query from broad_phase.as_query_pipeline(...) rather than stored on
RapierState (the API changed to return a borrowed view in 0.32).

11 new tests all green: apply_impulse linvel proportional to impulse/mass,
apply_force accel over multiple ticks, set_translation propagates,
set_linvel imperative override not clobbered, raycast hit and miss,
intersections_with_shape, fixed joint holds entities, despawn cleans up
joints, missing-entity ops no-panic, Fixed-body imperative no-op.

Verification: 87/87 lib tests pass, 3/3 doc examples compile, clippy clean
both feature configurations, fmt clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@martinjms
Copy link
Copy Markdown
Contributor Author

Self-review against #121 spec

Architecture fit

  • Stays inside arcane-infra::rapier_cluster. No replication / hot-path / clustering touches.
  • Lock-window restructure scoped to Backend::Rapier only. Backend::Cluster keeps legacy "lock released during user code" behavior — no change for plain ClusterSimulation users.
  • run_physics_phase extraction cleanly factors the despawn / spawn-with-skip / step / sync sequence shared across all three backend paths.

Spec compliance

  • All 14 imperative ops landed: apply_impulse / apply_force / apply_torque_impulse, set_translation / set_linvel / set_angvel, linvel / angvel, wake / sleep, raycast / intersections_with_shape, create_joint / remove_joint.
  • RaycastHit and JointSpec exactly match the type sketches in the refined issue body.
  • All four imperative-op semantics from the refined spec implemented: Fixed-body no-op + return false, missing-entity-id no-panic, set_translation may violate constraints (documented), set_linvel ↔ per-tick sync interaction handled via pending_imperative_linvel skip.

Test coverage

  • 11 new tests (one extra beyond the spec's 10) — all passing locally, all passing CI:
    • apply_impulse linvel proportional to impulse/mass
    • apply_force sustained → acceleration
    • set_translation propagates to entity.position
    • set_linvel imperative override not clobbered by per-tick sync
    • raycast hit + miss
    • intersections_with_shape near + far filtering
    • Fixed joint holds entities at fixed offset
    • Despawn cleans up attached joints
    • Missing-entity ops return false/None without panicking
    • Imperative ops on Fixed body are no-ops
  • All 76 prior rapier_cluster tests still pass after the lock-window restructure.

Rapier 0.32 API translation notes (deviation from spec) ⚠️ — surfaced in Decisions made in PR body

  • QueryPipeline is now a borrowed view (QueryPipeline<'a>); cannot be stored as a field on RapierState. Built transiently per-query via BroadPhaseBvh::as_query_pipeline(...). Functionally equivalent — broad_phase BVH is updated by physics_pipeline.step so all queries inside on_tick see the latest state.
  • Joint axes take Vector (auto-normalize via .normalize()); no UnitVector wrapper needed.
  • Pose (glamx) replaces Isometry for transient query positions.

CI:rust-checks SUCCESS (1m 17s).

Verdict: clean implementation, fully spec-compliant including the 6 refinements added to the issue body. Squash-merging into feat/rapier-cluster.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant